{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "ec547158",
   "metadata": {},
   "source": [
    "### imports"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "id": "a6ea781e",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Requirement already satisfied: scikit-learn==1.3.2 in /opt/conda/lib/python3.10/site-packages (1.3.2)\n",
      "Requirement already satisfied: numpy<2.0,>=1.17.3 in /opt/conda/lib/python3.10/site-packages (from scikit-learn==1.3.2) (1.26.2)\n",
      "Requirement already satisfied: scipy>=1.5.0 in /opt/conda/lib/python3.10/site-packages (from scikit-learn==1.3.2) (1.11.4)\n",
      "Requirement already satisfied: joblib>=1.1.1 in /opt/conda/lib/python3.10/site-packages (from scikit-learn==1.3.2) (1.3.2)\n",
      "Requirement already satisfied: threadpoolctl>=2.0.0 in /opt/conda/lib/python3.10/site-packages (from scikit-learn==1.3.2) (3.2.0)\n",
      "Requirement already satisfied: numpy==1.26.2 in /opt/conda/lib/python3.10/site-packages (1.26.2)\n",
      "Requirement already satisfied: pandas==1.4.3 in /opt/conda/lib/python3.10/site-packages (1.4.3)\n",
      "Requirement already satisfied: python-dateutil>=2.8.1 in /opt/conda/lib/python3.10/site-packages (from pandas==1.4.3) (2.8.2)\n",
      "Requirement already satisfied: pytz>=2020.1 in /opt/conda/lib/python3.10/site-packages (from pandas==1.4.3) (2022.1)\n",
      "Requirement already satisfied: numpy>=1.21.0 in /opt/conda/lib/python3.10/site-packages (from pandas==1.4.3) (1.26.2)\n",
      "Requirement already satisfied: six>=1.5 in /opt/conda/lib/python3.10/site-packages (from python-dateutil>=2.8.1->pandas==1.4.3) (1.16.0)\n",
      "Requirement already satisfied: torch==2.2 in /opt/conda/lib/python3.10/site-packages (2.2.0)\n",
      "Requirement already satisfied: filelock in /opt/conda/lib/python3.10/site-packages (from torch==2.2) (3.13.1)\n",
      "Requirement already satisfied: typing-extensions>=4.8.0 in /opt/conda/lib/python3.10/site-packages (from torch==2.2) (4.9.0)\n",
      "Requirement already satisfied: sympy in /opt/conda/lib/python3.10/site-packages (from torch==2.2) (1.12)\n",
      "Requirement already satisfied: networkx in /opt/conda/lib/python3.10/site-packages (from torch==2.2) (2.8.5)\n",
      "Requirement already satisfied: jinja2 in /opt/conda/lib/python3.10/site-packages (from torch==2.2) (3.1.2)\n",
      "Requirement already satisfied: fsspec in /opt/conda/lib/python3.10/site-packages (from torch==2.2) (2023.12.2)\n",
      "Requirement already satisfied: nvidia-cuda-nvrtc-cu12==12.1.105 in /opt/conda/lib/python3.10/site-packages (from torch==2.2) (12.1.105)\n",
      "Requirement already satisfied: nvidia-cuda-runtime-cu12==12.1.105 in /opt/conda/lib/python3.10/site-packages (from torch==2.2) (12.1.105)\n",
      "Requirement already satisfied: nvidia-cuda-cupti-cu12==12.1.105 in /opt/conda/lib/python3.10/site-packages (from torch==2.2) (12.1.105)\n",
      "Requirement already satisfied: nvidia-cudnn-cu12==8.9.2.26 in /opt/conda/lib/python3.10/site-packages (from torch==2.2) (8.9.2.26)\n",
      "Requirement already satisfied: nvidia-cublas-cu12==12.1.3.1 in /opt/conda/lib/python3.10/site-packages (from torch==2.2) (12.1.3.1)\n",
      "Requirement already satisfied: nvidia-cufft-cu12==11.0.2.54 in /opt/conda/lib/python3.10/site-packages (from torch==2.2) (11.0.2.54)\n",
      "Requirement already satisfied: nvidia-curand-cu12==10.3.2.106 in /opt/conda/lib/python3.10/site-packages (from torch==2.2) (10.3.2.106)\n",
      "Requirement already satisfied: nvidia-cusolver-cu12==11.4.5.107 in /opt/conda/lib/python3.10/site-packages (from torch==2.2) (11.4.5.107)\n",
      "Requirement already satisfied: nvidia-cusparse-cu12==12.1.0.106 in /opt/conda/lib/python3.10/site-packages (from torch==2.2) (12.1.0.106)\n",
      "Requirement already satisfied: nvidia-nccl-cu12==2.19.3 in /opt/conda/lib/python3.10/site-packages (from torch==2.2) (2.19.3)\n",
      "Requirement already satisfied: nvidia-nvtx-cu12==12.1.105 in /opt/conda/lib/python3.10/site-packages (from torch==2.2) (12.1.105)\n",
      "Requirement already satisfied: triton==2.2.0 in /opt/conda/lib/python3.10/site-packages (from torch==2.2) (2.2.0)\n",
      "Requirement already satisfied: nvidia-nvjitlink-cu12 in /opt/conda/lib/python3.10/site-packages (from nvidia-cusolver-cu12==11.4.5.107->torch==2.2) (12.3.101)\n",
      "Requirement already satisfied: MarkupSafe>=2.0 in /opt/conda/lib/python3.10/site-packages (from jinja2->torch==2.2) (2.1.3)\n",
      "Requirement already satisfied: mpmath>=0.19 in /opt/conda/lib/python3.10/site-packages (from sympy->torch==2.2) (1.3.0)\n"
     ]
    }
   ],
   "source": [
    "!pip install scikit-learn==1.3.2\n",
    "!pip install numpy==1.26.2\n",
    "!pip install pandas==1.4.3\n",
    "!pip install torch==2.2"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "id": "39f8a04a",
   "metadata": {},
   "outputs": [],
   "source": [
    "import os\n",
    "import math\n",
    "import copy\n",
    "from itertools import zip_longest\n",
    "\n",
    "import numpy as np\n",
    "import pandas as pd\n",
    "from sklearn.model_selection import train_test_split\n",
    "\n",
    "import torch\n",
    "from torch import nn\n",
    "from torch import optim"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2c256430",
   "metadata": {},
   "source": [
    "### set random seed for reproducibility"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "id": "cbbce5ef",
   "metadata": {},
   "outputs": [],
   "source": [
    "def set_random_seed(state=1):\n",
    "    gens = (np.random.seed, torch.manual_seed, torch.cuda.manual_seed)\n",
    "    for set_state in gens:\n",
    "        set_state(state)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "id": "08676a14",
   "metadata": {},
   "outputs": [],
   "source": [
    "RANDOM_STATE = 42\n",
    "set_random_seed(RANDOM_STATE)"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b895a888",
   "metadata": {},
   "source": [
    "### download dataset"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "id": "af21cb08",
   "metadata": {},
   "outputs": [],
   "source": [
    "DATASET_LINK=\"https://files.grouplens.org/datasets/movielens/ml-latest-small.zip\""
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "id": "27dc39b2",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "File ‘ml-latest-small.zip’ already there; not retrieving.\n",
      "\n",
      "Archive:  ml-latest-small.zip\n"
     ]
    }
   ],
   "source": [
    "!wget -nc $DATASET_LINK\n",
    "!unzip -n ml-latest-small.zip"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "efebdf4f",
   "metadata": {},
   "source": [
    "### load dataset"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "id": "bb874566",
   "metadata": {},
   "outputs": [],
   "source": [
    "def read_data(path):\n",
    "    files = {}\n",
    "    for filename in os.listdir(path):\n",
    "        stem, suffix =  os.path.splitext(filename)\n",
    "        file_path = os.path.join(path,filename)\n",
    "        print(filename)\n",
    "        if suffix == '.csv':\n",
    "            files[stem] = pd.read_csv(file_path)\n",
    "        elif suffix == '.dat':\n",
    "            if stem == 'ratings':\n",
    "                columns = ['userId', 'movieId', 'rating', 'timestamp']\n",
    "            else:\n",
    "                columns = ['movieId', 'title', 'genres']\n",
    "            data = pd.read_csv(file_path, sep='::', names=columns, engine='python')\n",
    "            files[stem] = data\n",
    "    return files['ratings'], files['movies']"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "id": "830f6f1b",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "movies.csv\n",
      "ratings.csv\n",
      "README.txt\n",
      "links.csv\n",
      "tags.csv\n"
     ]
    }
   ],
   "source": [
    "ratings, movies = read_data('./ml-latest-small/')"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 9,
   "id": "80483ccf",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>userId</th>\n",
       "      <th>movieId</th>\n",
       "      <th>rating</th>\n",
       "      <th>timestamp</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>0</th>\n",
       "      <td>1</td>\n",
       "      <td>1</td>\n",
       "      <td>4.0</td>\n",
       "      <td>964982703</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>1</th>\n",
       "      <td>1</td>\n",
       "      <td>3</td>\n",
       "      <td>4.0</td>\n",
       "      <td>964981247</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2</th>\n",
       "      <td>1</td>\n",
       "      <td>6</td>\n",
       "      <td>4.0</td>\n",
       "      <td>964982224</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>3</th>\n",
       "      <td>1</td>\n",
       "      <td>47</td>\n",
       "      <td>5.0</td>\n",
       "      <td>964983815</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>4</th>\n",
       "      <td>1</td>\n",
       "      <td>50</td>\n",
       "      <td>5.0</td>\n",
       "      <td>964982931</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "   userId  movieId  rating  timestamp\n",
       "0       1        1     4.0  964982703\n",
       "1       1        3     4.0  964981247\n",
       "2       1        6     4.0  964982224\n",
       "3       1       47     5.0  964983815\n",
       "4       1       50     5.0  964982931"
      ]
     },
     "execution_count": 9,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "ratings.head()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 10,
   "id": "56b4918a",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/plain": [
       "(0.5, 5.0)"
      ]
     },
     "execution_count": 10,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "minmax = ratings.rating.min(), ratings.rating.max()\n",
    "minmax"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 11,
   "id": "d7bee386",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>movieId</th>\n",
       "      <th>title</th>\n",
       "      <th>genres</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>0</th>\n",
       "      <td>1</td>\n",
       "      <td>Toy Story (1995)</td>\n",
       "      <td>Adventure|Animation|Children|Comedy|Fantasy</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>1</th>\n",
       "      <td>2</td>\n",
       "      <td>Jumanji (1995)</td>\n",
       "      <td>Adventure|Children|Fantasy</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2</th>\n",
       "      <td>3</td>\n",
       "      <td>Grumpier Old Men (1995)</td>\n",
       "      <td>Comedy|Romance</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>3</th>\n",
       "      <td>4</td>\n",
       "      <td>Waiting to Exhale (1995)</td>\n",
       "      <td>Comedy|Drama|Romance</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>4</th>\n",
       "      <td>5</td>\n",
       "      <td>Father of the Bride Part II (1995)</td>\n",
       "      <td>Comedy</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "   movieId                               title  \\\n",
       "0        1                    Toy Story (1995)   \n",
       "1        2                      Jumanji (1995)   \n",
       "2        3             Grumpier Old Men (1995)   \n",
       "3        4            Waiting to Exhale (1995)   \n",
       "4        5  Father of the Bride Part II (1995)   \n",
       "\n",
       "                                        genres  \n",
       "0  Adventure|Animation|Children|Comedy|Fantasy  \n",
       "1                   Adventure|Children|Fantasy  \n",
       "2                               Comedy|Romance  \n",
       "3                         Comedy|Drama|Romance  \n",
       "4                                       Comedy  "
      ]
     },
     "execution_count": 11,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "movies.head()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 12,
   "id": "ab05e616",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/plain": [
       "(9742, 3)"
      ]
     },
     "execution_count": 12,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "movies.drop_duplicates().shape"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 13,
   "id": "cc03525f",
   "metadata": {},
   "outputs": [],
   "source": [
    "ratings = ratings.merge(movies[[\"movieId\", \"title\"]], on=\"movieId\")"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 14,
   "id": "0b7d02a5",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>userId</th>\n",
       "      <th>movieId</th>\n",
       "      <th>rating</th>\n",
       "      <th>timestamp</th>\n",
       "      <th>title</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>0</th>\n",
       "      <td>1</td>\n",
       "      <td>1</td>\n",
       "      <td>4.0</td>\n",
       "      <td>964982703</td>\n",
       "      <td>Toy Story (1995)</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>1</th>\n",
       "      <td>5</td>\n",
       "      <td>1</td>\n",
       "      <td>4.0</td>\n",
       "      <td>847434962</td>\n",
       "      <td>Toy Story (1995)</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>2</th>\n",
       "      <td>7</td>\n",
       "      <td>1</td>\n",
       "      <td>4.5</td>\n",
       "      <td>1106635946</td>\n",
       "      <td>Toy Story (1995)</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>3</th>\n",
       "      <td>15</td>\n",
       "      <td>1</td>\n",
       "      <td>2.5</td>\n",
       "      <td>1510577970</td>\n",
       "      <td>Toy Story (1995)</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>4</th>\n",
       "      <td>17</td>\n",
       "      <td>1</td>\n",
       "      <td>4.5</td>\n",
       "      <td>1305696483</td>\n",
       "      <td>Toy Story (1995)</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "   userId  movieId  rating   timestamp             title\n",
       "0       1        1     4.0   964982703  Toy Story (1995)\n",
       "1       5        1     4.0   847434962  Toy Story (1995)\n",
       "2       7        1     4.5  1106635946  Toy Story (1995)\n",
       "3      15        1     2.5  1510577970  Toy Story (1995)\n",
       "4      17        1     4.5  1305696483  Toy Story (1995)"
      ]
     },
     "execution_count": 14,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "ratings.head()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 15,
   "id": "2ecbd7b9",
   "metadata": {},
   "outputs": [],
   "source": [
    "def tabular_preview(ratings, n=15):\n",
    "    \"\"\"Creates a cross-tabular view of users vs movies.\"\"\"\n",
    "    \n",
    "    user_groups = ratings.groupby('userId')['rating'].count()\n",
    "    top_users = user_groups.sort_values(ascending=False)[:n]\n",
    "\n",
    "    movie_groups = ratings.groupby('movieId')['rating'].count()\n",
    "    top_movies = movie_groups.sort_values(ascending=False)[:n]\n",
    "\n",
    "    top = (\n",
    "        ratings.\n",
    "        join(top_users, rsuffix='_r', how='inner', on='userId').\n",
    "        join(top_movies, rsuffix='_r', how='inner', on='movieId'))\n",
    "\n",
    "    return pd.crosstab(top.userId, top.movieId, top.rating, aggfunc=np.sum)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 16,
   "id": "2ac12fea",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th>movieId</th>\n",
       "      <th>110</th>\n",
       "      <th>260</th>\n",
       "      <th>296</th>\n",
       "      <th>318</th>\n",
       "      <th>356</th>\n",
       "      <th>480</th>\n",
       "      <th>527</th>\n",
       "      <th>589</th>\n",
       "      <th>593</th>\n",
       "      <th>2571</th>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>userId</th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "      <th></th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>68</th>\n",
       "      <td>2.5</td>\n",
       "      <td>5.0</td>\n",
       "      <td>2.0</td>\n",
       "      <td>3.0</td>\n",
       "      <td>3.5</td>\n",
       "      <td>3.5</td>\n",
       "      <td>4.0</td>\n",
       "      <td>3.5</td>\n",
       "      <td>3.5</td>\n",
       "      <td>4.5</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>274</th>\n",
       "      <td>4.5</td>\n",
       "      <td>3.0</td>\n",
       "      <td>5.0</td>\n",
       "      <td>4.5</td>\n",
       "      <td>4.5</td>\n",
       "      <td>3.5</td>\n",
       "      <td>4.0</td>\n",
       "      <td>4.5</td>\n",
       "      <td>4.0</td>\n",
       "      <td>4.0</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>288</th>\n",
       "      <td>5.0</td>\n",
       "      <td>5.0</td>\n",
       "      <td>5.0</td>\n",
       "      <td>5.0</td>\n",
       "      <td>5.0</td>\n",
       "      <td>2.0</td>\n",
       "      <td>5.0</td>\n",
       "      <td>4.0</td>\n",
       "      <td>5.0</td>\n",
       "      <td>3.0</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>380</th>\n",
       "      <td>4.0</td>\n",
       "      <td>5.0</td>\n",
       "      <td>5.0</td>\n",
       "      <td>3.0</td>\n",
       "      <td>5.0</td>\n",
       "      <td>5.0</td>\n",
       "      <td>NaN</td>\n",
       "      <td>5.0</td>\n",
       "      <td>5.0</td>\n",
       "      <td>4.5</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>414</th>\n",
       "      <td>5.0</td>\n",
       "      <td>5.0</td>\n",
       "      <td>5.0</td>\n",
       "      <td>5.0</td>\n",
       "      <td>5.0</td>\n",
       "      <td>4.0</td>\n",
       "      <td>4.0</td>\n",
       "      <td>5.0</td>\n",
       "      <td>4.0</td>\n",
       "      <td>5.0</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>448</th>\n",
       "      <td>NaN</td>\n",
       "      <td>5.0</td>\n",
       "      <td>5.0</td>\n",
       "      <td>NaN</td>\n",
       "      <td>3.0</td>\n",
       "      <td>3.0</td>\n",
       "      <td>NaN</td>\n",
       "      <td>3.0</td>\n",
       "      <td>5.0</td>\n",
       "      <td>2.0</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>474</th>\n",
       "      <td>3.0</td>\n",
       "      <td>4.0</td>\n",
       "      <td>4.0</td>\n",
       "      <td>5.0</td>\n",
       "      <td>3.0</td>\n",
       "      <td>4.5</td>\n",
       "      <td>5.0</td>\n",
       "      <td>4.0</td>\n",
       "      <td>4.5</td>\n",
       "      <td>4.5</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>599</th>\n",
       "      <td>3.5</td>\n",
       "      <td>5.0</td>\n",
       "      <td>5.0</td>\n",
       "      <td>4.0</td>\n",
       "      <td>3.5</td>\n",
       "      <td>4.0</td>\n",
       "      <td>NaN</td>\n",
       "      <td>4.5</td>\n",
       "      <td>3.0</td>\n",
       "      <td>5.0</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>606</th>\n",
       "      <td>3.5</td>\n",
       "      <td>4.5</td>\n",
       "      <td>5.0</td>\n",
       "      <td>3.5</td>\n",
       "      <td>4.0</td>\n",
       "      <td>2.5</td>\n",
       "      <td>5.0</td>\n",
       "      <td>3.5</td>\n",
       "      <td>4.5</td>\n",
       "      <td>5.0</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>610</th>\n",
       "      <td>4.5</td>\n",
       "      <td>5.0</td>\n",
       "      <td>5.0</td>\n",
       "      <td>3.0</td>\n",
       "      <td>3.0</td>\n",
       "      <td>5.0</td>\n",
       "      <td>3.5</td>\n",
       "      <td>5.0</td>\n",
       "      <td>4.5</td>\n",
       "      <td>5.0</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "</div>"
      ],
      "text/plain": [
       "movieId  110   260   296   318   356   480   527   589   593   2571\n",
       "userId                                                             \n",
       "68        2.5   5.0   2.0   3.0   3.5   3.5   4.0   3.5   3.5   4.5\n",
       "274       4.5   3.0   5.0   4.5   4.5   3.5   4.0   4.5   4.0   4.0\n",
       "288       5.0   5.0   5.0   5.0   5.0   2.0   5.0   4.0   5.0   3.0\n",
       "380       4.0   5.0   5.0   3.0   5.0   5.0   NaN   5.0   5.0   4.5\n",
       "414       5.0   5.0   5.0   5.0   5.0   4.0   4.0   5.0   4.0   5.0\n",
       "448       NaN   5.0   5.0   NaN   3.0   3.0   NaN   3.0   5.0   2.0\n",
       "474       3.0   4.0   4.0   5.0   3.0   4.5   5.0   4.0   4.5   4.5\n",
       "599       3.5   5.0   5.0   4.0   3.5   4.0   NaN   4.5   3.0   5.0\n",
       "606       3.5   4.5   5.0   3.5   4.0   2.5   5.0   3.5   4.5   5.0\n",
       "610       4.5   5.0   5.0   3.0   3.0   5.0   3.5   5.0   4.5   5.0"
      ]
     },
     "execution_count": 16,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "tabular_preview(ratings, 10)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 17,
   "id": "23c1f9f9",
   "metadata": {},
   "outputs": [],
   "source": [
    "def create_dataset(ratings, top=None):\n",
    "    if top is not None:\n",
    "        ratings.groupby('userId')['rating'].count()\n",
    "    \n",
    "    unique_users = ratings.userId.unique()\n",
    "    user_to_index = {old: new for new, old in enumerate(unique_users)}\n",
    "    new_users = ratings.userId.map(user_to_index)\n",
    "    \n",
    "    unique_movies = ratings.movieId.unique()\n",
    "    movie_to_index = {old: new for new, old in enumerate(unique_movies)}\n",
    "    new_movies = ratings.movieId.map(movie_to_index)\n",
    "        \n",
    "    n_users = unique_users.shape[0]\n",
    "    n_movies = unique_movies.shape[0]\n",
    "    \n",
    "    X = pd.DataFrame({'user_id': new_users, 'movie_id': new_movies})\n",
    "    y = ratings['rating'].astype(np.float32)\n",
    "    return (n_users, n_movies), (X, y), (user_to_index, movie_to_index)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 18,
   "id": "562dfed4",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Embeddings: 610 users, 9724 movies\n",
      "Dataset shape: (100836, 2)\n",
      "Target shape: (100836,)\n"
     ]
    }
   ],
   "source": [
    "(n, m), (X, y), (user_to_index, movie_to_index) = create_dataset(ratings)\n",
    "print(f'Embeddings: {n} users, {m} movies')\n",
    "print(f'Dataset shape: {X.shape}')\n",
    "print(f'Target shape: {y.shape}')"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a9b01ad1",
   "metadata": {},
   "source": [
    "### create dataloader"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 19,
   "id": "461b564e",
   "metadata": {},
   "outputs": [],
   "source": [
    "class ReviewsIterator:\n",
    "    \n",
    "    def __init__(self, X, y, batch_size=32, shuffle=True):\n",
    "        X, y = np.asarray(X), np.asarray(y)\n",
    "        \n",
    "        if shuffle:\n",
    "            index = np.random.permutation(X.shape[0])\n",
    "            X, y = X[index], y[index]\n",
    "            \n",
    "        self.X = X\n",
    "        self.y = y\n",
    "        self.batch_size = batch_size\n",
    "        self.shuffle = shuffle\n",
    "        self.n_batches = int(math.ceil(X.shape[0] // batch_size))\n",
    "        self._current = 0\n",
    "        \n",
    "    def __iter__(self):\n",
    "        return self\n",
    "    \n",
    "    def __next__(self):\n",
    "        return self.next()\n",
    "    \n",
    "    def next(self):\n",
    "        if self._current >= self.n_batches:\n",
    "            raise StopIteration()\n",
    "        k = self._current\n",
    "        self._current += 1\n",
    "        bs = self.batch_size\n",
    "        return self.X[k*bs:(k + 1)*bs], self.y[k*bs:(k + 1)*bs]"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 20,
   "id": "fc07fdf7",
   "metadata": {},
   "outputs": [],
   "source": [
    "def batches(X, y, bs=32, shuffle=True):\n",
    "    for xb, yb in ReviewsIterator(X, y, bs, shuffle):\n",
    "        xb = torch.LongTensor(xb)\n",
    "        yb = torch.FloatTensor(yb)\n",
    "        yield xb, yb.view(-1, 1) "
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 21,
   "id": "63861618",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "tensor([[ 341, 1891],\n",
      "        [  83,  907],\n",
      "        [ 106, 5749],\n",
      "        [ 146,   61]])\n",
      "tensor([[4.],\n",
      "        [2.],\n",
      "        [4.],\n",
      "        [5.]])\n"
     ]
    }
   ],
   "source": [
    "for x_batch, y_batch in batches(X, y, bs=4):\n",
    "    print(x_batch)\n",
    "    print(y_batch)\n",
    "    break"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 22,
   "id": "361dee73",
   "metadata": {},
   "outputs": [],
   "source": [
    "X_train, X_valid, y_train, y_valid = train_test_split(X, y, test_size=0.2, random_state=RANDOM_STATE)\n",
    "datasets = {'train': (X_train, y_train), 'val': (X_valid, y_valid)}\n",
    "dataset_sizes = {'train': len(X_train), 'val': len(X_valid)}"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "316f7f94",
   "metadata": {},
   "source": [
    "### define recsys model"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 23,
   "id": "106018f1",
   "metadata": {},
   "outputs": [],
   "source": [
    "class EmbeddingNet(nn.Module):\n",
    "    \"\"\"\n",
    "    Creates a dense network with embedding layers.\n",
    "    \n",
    "    Args:\n",
    "    \n",
    "        n_users:            \n",
    "            Number of unique users in the dataset.\n",
    "\n",
    "        n_movies: \n",
    "            Number of unique movies in the dataset.\n",
    "\n",
    "        n_factors: \n",
    "            Number of columns in the embeddings matrix.\n",
    "\n",
    "        embedding_dropout: \n",
    "            Dropout rate to apply right after embeddings layer.\n",
    "\n",
    "        hidden:\n",
    "            A single integer or a list of integers defining the number of \n",
    "            units in hidden layer(s).\n",
    "\n",
    "        dropouts: \n",
    "            A single integer or a list of integers defining the dropout \n",
    "            layers rates applyied right after each of hidden layers.\n",
    "            \n",
    "    \"\"\"\n",
    "    def __init__(self, n_users, n_movies,\n",
    "                 n_factors=50, embedding_dropout=0.02, \n",
    "                 hidden=10, dropouts=0.2):\n",
    "        super().__init__()\n",
    "        hidden = get_list(hidden)\n",
    "        dropouts = get_list(dropouts)\n",
    "        n_last = hidden[-1]\n",
    "        \n",
    "        def gen_layers(n_in):\n",
    "            \"\"\"\n",
    "            A generator that yields a sequence of hidden layers and \n",
    "            their activations/dropouts.\n",
    "            \n",
    "            Note that the function captures `hidden` and `dropouts` \n",
    "            values from the outer scope.\n",
    "            \"\"\"\n",
    "            nonlocal hidden, dropouts\n",
    "            assert len(dropouts) <= len(hidden)\n",
    "            \n",
    "            for n_out, rate in zip_longest(hidden, dropouts):\n",
    "                yield nn.Linear(n_in, n_out)\n",
    "                yield nn.ReLU()\n",
    "                if rate is not None and rate > 0.:\n",
    "                    yield nn.Dropout(rate)\n",
    "                n_in = n_out\n",
    "            \n",
    "        self.u = nn.Embedding(n_users, n_factors)\n",
    "        self.m = nn.Embedding(n_movies, n_factors)\n",
    "        self.drop = nn.Dropout(embedding_dropout)\n",
    "        self.hidden = nn.Sequential(*list(gen_layers(n_factors * 2)))\n",
    "        self.fc = nn.Linear(n_last, 1)\n",
    "        self._init()\n",
    "        \n",
    "    def forward(self, users, movies, minmax=None):\n",
    "        features = torch.cat([self.u(users), self.m(movies)], dim=1)\n",
    "        x = self.drop(features)\n",
    "        x = self.hidden(x)\n",
    "        out = torch.sigmoid(self.fc(x))\n",
    "        if minmax is not None:\n",
    "            min_rating, max_rating = minmax\n",
    "            out = out*(max_rating - min_rating + 1) + min_rating - 0.5\n",
    "        return out\n",
    "    \n",
    "    def _init(self):\n",
    "        \"\"\"\n",
    "        Setup embeddings and hidden layers with reasonable initial values.\n",
    "        \"\"\"\n",
    "        def init(m):\n",
    "            if type(m) == nn.Linear:\n",
    "                torch.nn.init.xavier_uniform_(m.weight)\n",
    "                m.bias.data.fill_(0.01)\n",
    "                \n",
    "        self.u.weight.data.uniform_(-0.05, 0.05)\n",
    "        self.m.weight.data.uniform_(-0.05, 0.05)\n",
    "        self.hidden.apply(init)\n",
    "        init(self.fc)\n",
    "    \n",
    "    \n",
    "def get_list(n):\n",
    "    if isinstance(n, (int, float)):\n",
    "        return [n]\n",
    "    elif hasattr(n, '__iter__'):\n",
    "        return list(n)\n",
    "    raise TypeError('layers configuraiton should be a single number or a list of numbers')"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 24,
   "id": "1a002fbd",
   "metadata": {},
   "outputs": [],
   "source": [
    "net = EmbeddingNet(\n",
    "    n_users=n, n_movies=m, \n",
    "    n_factors=150, hidden=[500, 500, 500], \n",
    "    embedding_dropout=0.05, dropouts=[0.5, 0.5, 0.25])"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 25,
   "id": "b6eed23a",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/plain": [
       "EmbeddingNet(\n",
       "  (u): Embedding(610, 150)\n",
       "  (m): Embedding(9724, 150)\n",
       "  (drop): Dropout(p=0.05, inplace=False)\n",
       "  (hidden): Sequential(\n",
       "    (0): Linear(in_features=300, out_features=500, bias=True)\n",
       "    (1): ReLU()\n",
       "    (2): Dropout(p=0.5, inplace=False)\n",
       "    (3): Linear(in_features=500, out_features=500, bias=True)\n",
       "    (4): ReLU()\n",
       "    (5): Dropout(p=0.5, inplace=False)\n",
       "    (6): Linear(in_features=500, out_features=500, bias=True)\n",
       "    (7): ReLU()\n",
       "    (8): Dropout(p=0.25, inplace=False)\n",
       "  )\n",
       "  (fc): Linear(in_features=500, out_features=1, bias=True)\n",
       ")"
      ]
     },
     "execution_count": 25,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "net"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c986d497",
   "metadata": {},
   "source": [
    "### model training loop"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 26,
   "id": "ef43de4c",
   "metadata": {},
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "/opt/conda/lib/python3.10/site-packages/transformers/utils/generic.py:441: UserWarning: torch.utils._pytree._register_pytree_node is deprecated. Please use torch.utils._pytree.register_pytree_node instead.\n",
      "  _torch_pytree._register_pytree_node(\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "loss improvement on epoch: 1\n",
      "[001/200] train: 1.1996 - val: 1.0651\n",
      "loss improvement on epoch: 2\n",
      "[002/200] train: 1.0806 - val: 1.0494\n",
      "loss improvement on epoch: 3\n",
      "[003/200] train: 1.0632 - val: 1.0300\n",
      "loss improvement on epoch: 4\n",
      "[004/200] train: 1.0376 - val: 0.9992\n",
      "loss improvement on epoch: 5\n",
      "[005/200] train: 0.9992 - val: 0.9553\n",
      "loss improvement on epoch: 6\n",
      "[006/200] train: 0.9511 - val: 0.9160\n",
      "loss improvement on epoch: 7\n",
      "[007/200] train: 0.9073 - val: 0.8842\n",
      "loss improvement on epoch: 8\n",
      "[008/200] train: 0.8785 - val: 0.8654\n",
      "loss improvement on epoch: 9\n",
      "[009/200] train: 0.8545 - val: 0.8508\n",
      "loss improvement on epoch: 10\n",
      "[010/200] train: 0.8364 - val: 0.8395\n",
      "loss improvement on epoch: 11\n",
      "[011/200] train: 0.8192 - val: 0.8360\n",
      "loss improvement on epoch: 12\n",
      "[012/200] train: 0.8053 - val: 0.8224\n",
      "loss improvement on epoch: 13\n",
      "[013/200] train: 0.7895 - val: 0.8203\n",
      "loss improvement on epoch: 14\n",
      "[014/200] train: 0.7779 - val: 0.8162\n",
      "loss improvement on epoch: 15\n",
      "[015/200] train: 0.7675 - val: 0.8094\n",
      "loss improvement on epoch: 16\n",
      "[016/200] train: 0.7576 - val: 0.8031\n",
      "loss improvement on epoch: 17\n",
      "[017/200] train: 0.7487 - val: 0.8008\n",
      "loss improvement on epoch: 18\n",
      "[018/200] train: 0.7407 - val: 0.7978\n",
      "loss improvement on epoch: 19\n",
      "[019/200] train: 0.7294 - val: 0.7919\n",
      "loss improvement on epoch: 20\n",
      "[020/200] train: 0.7241 - val: 0.7912\n",
      "[021/200] train: 0.7160 - val: 0.7924\n",
      "loss improvement on epoch: 22\n",
      "[022/200] train: 0.7119 - val: 0.7877\n",
      "loss improvement on epoch: 23\n",
      "[023/200] train: 0.7058 - val: 0.7843\n",
      "[024/200] train: 0.7033 - val: 0.7878\n",
      "loss improvement on epoch: 25\n",
      "[025/200] train: 0.6957 - val: 0.7810\n",
      "[026/200] train: 0.6898 - val: 0.7813\n",
      "[027/200] train: 0.6836 - val: 0.7823\n",
      "[028/200] train: 0.6811 - val: 0.7815\n",
      "loss improvement on epoch: 29\n",
      "[029/200] train: 0.6783 - val: 0.7792\n",
      "loss improvement on epoch: 30\n",
      "[030/200] train: 0.6755 - val: 0.7769\n",
      "[031/200] train: 0.6715 - val: 0.7811\n",
      "[032/200] train: 0.6690 - val: 0.7794\n",
      "[033/200] train: 0.6631 - val: 0.7799\n",
      "loss improvement on epoch: 34\n",
      "[034/200] train: 0.6615 - val: 0.7727\n",
      "[035/200] train: 0.6599 - val: 0.7747\n",
      "[036/200] train: 0.6566 - val: 0.7736\n",
      "[037/200] train: 0.6546 - val: 0.7737\n",
      "[038/200] train: 0.6540 - val: 0.7777\n",
      "loss improvement on epoch: 39\n",
      "[039/200] train: 0.6497 - val: 0.7697\n",
      "[040/200] train: 0.6483 - val: 0.7790\n",
      "[041/200] train: 0.6478 - val: 0.7701\n",
      "[042/200] train: 0.6445 - val: 0.7740\n",
      "[043/200] train: 0.6428 - val: 0.7775\n",
      "[044/200] train: 0.6413 - val: 0.7772\n",
      "[045/200] train: 0.6398 - val: 0.7759\n",
      "[046/200] train: 0.6369 - val: 0.7781\n",
      "[047/200] train: 0.6349 - val: 0.7768\n",
      "[048/200] train: 0.6331 - val: 0.7778\n",
      "[049/200] train: 0.6326 - val: 0.7714\n",
      "early stopping after epoch 049\n"
     ]
    }
   ],
   "source": [
    "lr = 1e-5\n",
    "wd = 1e-5\n",
    "bs = 200 \n",
    "n_epochs = 200\n",
    "patience = 10\n",
    "no_improvements = 0\n",
    "best_loss = np.inf\n",
    "best_weights = None\n",
    "history = []\n",
    "\n",
    "device = torch.device('cuda:0' if torch.cuda.is_available() else 'cpu')\n",
    "\n",
    "net.to(device)\n",
    "criterion = nn.MSELoss(reduction='sum')\n",
    "optimizer = optim.Adam(net.parameters(), lr=lr, weight_decay=wd)\n",
    "iterations_per_epoch = int(math.ceil(dataset_sizes['train'] // bs))\n",
    "\n",
    "for epoch in range(n_epochs):\n",
    "    stats = {'epoch': epoch + 1, 'total': n_epochs}\n",
    "    \n",
    "    for phase in ('train', 'val'):\n",
    "        training = phase == 'train'\n",
    "        running_loss = 0.0\n",
    "        n_batches = 0\n",
    "        batch_num = 0\n",
    "        for batch in batches(*datasets[phase], shuffle=training, bs=bs):\n",
    "            x_batch, y_batch = [b.to(device) for b in batch]\n",
    "            optimizer.zero_grad()\n",
    "            # compute gradients only during 'train' phase\n",
    "            with torch.set_grad_enabled(training):\n",
    "                outputs = net(x_batch[:, 0], x_batch[:, 1], minmax)\n",
    "                loss = criterion(outputs, y_batch)\n",
    "                \n",
    "                # don't update weights and rates when in 'val' phase\n",
    "                if training:\n",
    "                    loss.backward()\n",
    "                    optimizer.step()\n",
    "                    \n",
    "            running_loss += loss.item()\n",
    "            \n",
    "        epoch_loss = running_loss / dataset_sizes[phase]\n",
    "        stats[phase] = epoch_loss\n",
    "        \n",
    "        # early stopping: save weights of the best model so far\n",
    "        if phase == 'val':\n",
    "            if epoch_loss < best_loss:\n",
    "                print('loss improvement on epoch: %d' % (epoch + 1))\n",
    "                best_loss = epoch_loss\n",
    "                best_weights = copy.deepcopy(net.state_dict())\n",
    "                no_improvements = 0\n",
    "            else:\n",
    "                no_improvements += 1\n",
    "                \n",
    "    history.append(stats)\n",
    "    print('[{epoch:03d}/{total:03d}] train: {train:.4f} - val: {val:.4f}'.format(**stats))\n",
    "    if no_improvements >= patience:\n",
    "        print('early stopping after epoch {epoch:03d}'.format(**stats))\n",
    "        break"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 27,
   "id": "6d557cc2",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "image/png": "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3LkzcXFxdf4cCgsLWbFihf4czoBhGNxxxx18+OGHfPPNN3Tu3LnO/iFDhuDn51fnvG/dupV9+/bpvDczt9uN0+nUOfegCy+8kA0bNrBu3braZejQoVx//fW16zr3nldcXMzOnTuJj4/37O/7GXV/bYVmz55tOBwOY9asWcamTZuM2267zYiIiDCysrKsLq1NKSoqMtauXWusXbvWAIynn37aWLt2rbF3717DMAzj8ccfNyIiIoyPPvrIWL9+vXHppZcanTt3NsrKyiyu3Hv95je/McLDw42FCxcamZmZtUtpaWntMbfffruRkpJifPPNN8bq1auNESNGGCNGjLCwau933333GYsWLTJ2795trF+/3rjvvvsMm81mfPXVV4Zh6Jy3pGNH0xiGzr0n/P73vzcWLlxo7N6921i6dKkxevRoIyoqysjJyTEMw3PnvM2FEcMwjOeee85ISUkx/P39jeHDhxvff/+91SW1Od9++60BnLBMmjTJMAxzeO+DDz5oxMbGGg6Hw7jwwguNrVu3Wlu0l6vvfAPGa6+9VntMWVmZ8dvf/tbo0KGDERQUZEyYMMHIzMy0rug24JZbbjE6depk+Pv7G9HR0caFF15YG0QMQ+e8JR0fRnTum9/EiRON+Ph4w9/f30hMTDQmTpxo7Nixo3a/p865zTAM48zaVkREREROX5vqMyIiIiLeR2FERERELKUwIiIiIpZSGBERERFLKYyIiIiIpRRGRERExFIKIyIiImIphRERERGxlMKIiHidhQsXYrPZTrhhl4h4J4URERERsZTCiIiIiFhKYUREmsztdjN9+nQ6d+5MYGAgaWlpvPfee8DRSyjz5s1jwIABBAQEcNZZZ7Fx48Y6r/H+++/Tt29fHA4HqampPPXUU3X2O51O/vjHP5KcnIzD4aBbt2688sordY5Zs2YNQ4cOJSgoiJEjR7J161bPfnAR8QiFERFpsunTp/P666/zwgsv8NNPP3H33Xdzww03sGjRotpj/vCHP/DUU0+xatUqoqOjGT9+PJWVlYAZIq6++mquueYaNmzYwCOPPMKDDz7IrFmzap9/00038fbbb/Pss8+yefNmXnzxRUJCQurU8cADD/DUU0+xevVqfH19ueWWW1rk84tIMzvj+/6KSLtSXl5uBAUFGcuWLauz/Ze//KVx7bXXGt9++60BGLNnz67dd/jwYSMwMNCYM2eOYRiGcd111xkXXXRRnef/4Q9/MPr06WMYhmFs3brVAIz58+fXW0PNe3z99de12+bNm2cARllZWbN8ThFpOWoZEZEm2bFjB6WlpVx00UWEhITULq+//jo7d+6sPW7EiBG165GRkfTs2ZPNmzcDsHnzZs4+++w6r3v22Wezfft2XC4X69atw8fHh/POO++ktQwYMKB2PT4+HoCcnJwz/owi0rJ8rS5ARLxLcXExAPPmzSMxMbHOPofDUSeQnK7AwMBGHefn51e7brPZALM/i4h4F7WMiEiT9OnTB4fDwb59++jWrVudJTk5ufa477//vnb9yJEjbNu2jd69ewPQu3dvli5dWud1ly5dSo8ePfDx8aF///643e46fVBEpO1Sy4iINEloaCj33HMPd999N263m3POOYeCggKWLl1KWFgYnTp1AuDRRx+lY8eOxMbG8sADDxAVFcVll10GwO9//3uGDRvGY489xsSJE1m+fDnPP/88//rXvwBITU1l0qRJ3HLLLTz77LOkpaWxd+9ecnJyuPrqq6366CLiIQojItJkjz32GNHR0UyfPp1du3YRERHB4MGDuf/++2svkzz++OPcddddbN++nYEDB/LJJ5/g7+8PwODBg3nnnXd46KGHeOyxx4iPj+fRRx9l8uTJte/x73//m/vvv5/f/va3HD58mJSUFO6//34rPq6IeJjNMAzD6iJEpO1YuHAho0aN4siRI0RERFhdjoh4AfUZEREREUspjIiIiIildJlGRERELKWWEREREbGUwoiIiIhYSmFERERELKUwIiIiIpZSGBERERFLKYyIiIiIpRRGRERExFIKIyIiImKp/w+6sxn1yQQAUgAAAABJRU5ErkJggg==",
      "text/plain": [
       "<Figure size 640x480 with 1 Axes>"
      ]
     },
     "metadata": {},
     "output_type": "display_data"
    }
   ],
   "source": [
    "ax = pd.DataFrame(history).drop(columns='total').plot(x='epoch')"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4d6f55ed",
   "metadata": {},
   "source": [
    "### evaluate model on validation set"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 28,
   "id": "d2eb35c8",
   "metadata": {},
   "outputs": [],
   "source": [
    "groud_truth, predictions = [], []\n",
    "\n",
    "with torch.no_grad():\n",
    "    for batch in batches(*datasets['val'], shuffle=False, bs=bs):\n",
    "        x_batch, y_batch = [b.to(device) for b in batch]\n",
    "        outputs = net(x_batch[:, 0], x_batch[:, 1], minmax)\n",
    "        groud_truth.extend(y_batch.tolist())\n",
    "        predictions.extend(outputs.tolist())\n",
    "\n",
    "groud_truth = np.asarray(groud_truth).ravel()\n",
    "predictions = np.asarray(predictions).ravel()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 29,
   "id": "7b1c4a6b",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Final RMSE: 0.8816\n"
     ]
    }
   ],
   "source": [
    "final_loss = np.sqrt(np.mean((np.array(predictions) - np.array(groud_truth))**2))\n",
    "print(f'Final RMSE: {final_loss:.4f}')"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 30,
   "id": "ee6e04ed",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/plain": [
       "array([3.22362065, 2.88916826, 3.34224343, ..., 3.88819361, 3.34277844,\n",
       "       3.67311811])"
      ]
     },
     "execution_count": 30,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "np.array(predictions)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 31,
   "id": "5103f29a",
   "metadata": {},
   "outputs": [
    {
     "data": {
      "text/html": [
       "<div>\n",
       "<style scoped>\n",
       "    .dataframe tbody tr th:only-of-type {\n",
       "        vertical-align: middle;\n",
       "    }\n",
       "\n",
       "    .dataframe tbody tr th {\n",
       "        vertical-align: top;\n",
       "    }\n",
       "\n",
       "    .dataframe thead th {\n",
       "        text-align: right;\n",
       "    }\n",
       "</style>\n",
       "<table border=\"1\" class=\"dataframe\">\n",
       "  <thead>\n",
       "    <tr style=\"text-align: right;\">\n",
       "      <th></th>\n",
       "      <th>user_id</th>\n",
       "      <th>movie_id</th>\n",
       "    </tr>\n",
       "  </thead>\n",
       "  <tbody>\n",
       "    <tr>\n",
       "      <th>67037</th>\n",
       "      <td>341</td>\n",
       "      <td>1891</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>42175</th>\n",
       "      <td>83</td>\n",
       "      <td>907</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>93850</th>\n",
       "      <td>106</td>\n",
       "      <td>5749</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>6187</th>\n",
       "      <td>146</td>\n",
       "      <td>61</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>12229</th>\n",
       "      <td>267</td>\n",
       "      <td>154</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>...</th>\n",
       "      <td>...</td>\n",
       "      <td>...</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>57416</th>\n",
       "      <td>102</td>\n",
       "      <td>1378</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>67290</th>\n",
       "      <td>83</td>\n",
       "      <td>1898</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>33423</th>\n",
       "      <td>157</td>\n",
       "      <td>696</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>98552</th>\n",
       "      <td>154</td>\n",
       "      <td>7775</td>\n",
       "    </tr>\n",
       "    <tr>\n",
       "      <th>87803</th>\n",
       "      <td>267</td>\n",
       "      <td>4174</td>\n",
       "    </tr>\n",
       "  </tbody>\n",
       "</table>\n",
       "<p>20168 rows × 2 columns</p>\n",
       "</div>"
      ],
      "text/plain": [
       "       user_id  movie_id\n",
       "67037      341      1891\n",
       "42175       83       907\n",
       "93850      106      5749\n",
       "6187       146        61\n",
       "12229      267       154\n",
       "...        ...       ...\n",
       "57416      102      1378\n",
       "67290       83      1898\n",
       "33423      157       696\n",
       "98552      154      7775\n",
       "87803      267      4174\n",
       "\n",
       "[20168 rows x 2 columns]"
      ]
     },
     "execution_count": 31,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "datasets[\"val\"][0]"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 32,
   "id": "ebc0e488",
   "metadata": {
    "scrolled": true
   },
   "outputs": [
    {
     "data": {
      "text/plain": [
       "67037    4.0\n",
       "42175    2.0\n",
       "93850    4.0\n",
       "6187     5.0\n",
       "12229    4.0\n",
       "        ... \n",
       "73415    5.0\n",
       "59374    4.0\n",
       "89587    3.5\n",
       "30918    4.5\n",
       "46482    4.5\n",
       "Name: rating, Length: 20000, dtype: float32"
      ]
     },
     "execution_count": 32,
     "metadata": {},
     "output_type": "execute_result"
    }
   ],
   "source": [
    "datasets[\"val\"][1][:20000]"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "82d352bd",
   "metadata": {},
   "source": [
    "### build a recommendation system using trained model"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 33,
   "id": "14e7b21c",
   "metadata": {},
   "outputs": [],
   "source": [
    "device=\"cpu\"\n",
    "def recommender_system(user_id, model, n_movies):\n",
    "    model = model.to(device)\n",
    "    seen_movies = set(X[X['user_id'] == user_id]['movie_id'])\n",
    "    print(f\"Total movies seen by the user: {len(seen_movies)}\")\n",
    "    user_ratings = y[X['user_id'] == user_id]\n",
    "    print(\"=====================================================================\")\n",
    "    print(f\"Some top rated movies (rating = {user_ratings.max()}) seen by the user:\")\n",
    "    print(\"=====================================================================\\n\")\n",
    "    top_rated_movie_ids = X.loc[(X['user_id'] == user_id) & (y == user_ratings.max()), \"movie_id\"]\n",
    "    print(\"\\n\".join(movies[movies.movieId.isin(top_rated_movie_ids)].title.iloc[:10].tolist()))\n",
    "    print(\"\")\n",
    "    \n",
    "    unseen_movies = list(set(ratings.movieId) - set(seen_movies))\n",
    "    unseen_movies_index = [movie_to_index[i] for i in unseen_movies]\n",
    "    \n",
    "    model_input = (torch.tensor([user_id]*len(unseen_movies_index), device=device), \n",
    "                   torch.tensor(unseen_movies_index, device=device))\n",
    "    \n",
    "    with torch.no_grad():\n",
    "        predicted_ratings = model(*model_input, minmax).detach().numpy()\n",
    "    \n",
    "    zipped_pred = zip(unseen_movies, predicted_ratings)\n",
    "    sorted_movie_index = list(zip(*sorted(zipped_pred, key=lambda c: c[1], reverse=True)))[0]\n",
    "    recommended_movies = movies[movies.movieId.isin(sorted_movie_index)].title.tolist()\n",
    "    \n",
    "    print(\"=====================================================================\")\n",
    "    print(\"Top \"+str(n_movies)+\" Movie recommendations for the user \"+str(user_id)+ \" are:\")\n",
    "    print(\"=====================================================================\\n\")\n",
    "    print(\"\\n\".join(recommended_movies[:n_movies]))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 34,
   "id": "fcd48302",
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "Total movies seen by the user: 575\n",
      "=====================================================================\n",
      "Some top rated movies (rating = 5.0) seen by the user:\n",
      "=====================================================================\n",
      "\n",
      "Jumanji (1995)\n",
      "GoldenEye (1995)\n",
      "Friday (1995)\n",
      "From Dusk Till Dawn (1996)\n",
      "Fair Game (1995)\n",
      "Big Bully (1996)\n",
      "Screamers (1995)\n",
      "Nico Icon (1995)\n",
      "Doom Generation, The (1995)\n",
      "Mighty Morphin Power Rangers: The Movie (1995)\n",
      "\n",
      "=====================================================================\n",
      "Top 20 Movie recommendations for the user 32 are:\n",
      "=====================================================================\n",
      "\n",
      "Father of the Bride Part II (1995)\n",
      "Heat (1995)\n",
      "Sudden Death (1995)\n",
      "American President, The (1995)\n",
      "Four Rooms (1995)\n",
      "Get Shorty (1995)\n",
      "Assassins (1995)\n",
      "Powder (1995)\n",
      "Persuasion (1995)\n",
      "It Takes Two (1995)\n",
      "Clueless (1995)\n",
      "Restoration (1995)\n",
      "How to Make an American Quilt (1995)\n",
      "Seven (a.k.a. Se7en) (1995)\n",
      "Pocahontas (1995)\n",
      "When Night Is Falling (1995)\n",
      "Usual Suspects, The (1995)\n",
      "Mighty Aphrodite (1995)\n",
      "Lamerica (1994)\n",
      "Big Green, The (1995)\n"
     ]
    }
   ],
   "source": [
    "recommender_system(32, net, 20)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "890d1c51",
   "metadata": {},
   "outputs": [],
   "source": []
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "5b6d580e",
   "metadata": {},
   "outputs": [],
   "source": []
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3 (Local)",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.10.13"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
